iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0
Modern Web

用 Effect 實現產品級軟體系列 第 17

[學習 Effect Day17] Effect 進階錯誤管理 (三)

  • 分享至 

  • xImage
  •  

這一篇帶你用最直覺的方式,理解 Timing Out 相關的 Effect API。
每個段落先用白話說明,再附上可直接跑的程式碼範例;所有
console.log 都改成中文,讓你邊看邊跑更有感。

逾時(Timing Out)

外部呼叫(API / DB / I/O)有時會變慢甚至卡住。Effect.timeout
能幫任務設定時間上限;如果超時,就以 TimeoutException 失敗,
避免整個流程無限等待。

import { Effect } from "effect";

// 1 秒完成的任務(會成功)
const makeShortTask = () => {
  return Effect.gen(function*() {
    console.log("[短任務] 開始")
    yield* Effect.sleep("1 second")
    console.log("[短任務] 結束")
    return "OK"
  })
}

// 2 秒完成的任務(若時限太短會逾時)
const makeLongTask = () => {
  return Effect.gen(function*() {
    console.log("[長任務] 開始")
    yield* Effect.sleep("2 seconds")
    console.log("[長任務] 結束")
    return "DONE"
  })
}

const runBasicTimeout = async (): Promise<void> => {
  console.log("\n--- DEMO:基本 timeout 行為 ---")

  // A) 在時限內完成 ⇒ 成功
  const ok = await Effect.runPromise(
    makeShortTask().pipe(Effect.timeout("3 seconds"))
  )
  console.log("時限內完成 =>", ok) // "OK"

  // B) 超過時限 ⇒ 以 TimeoutException 失敗
  const exit = await Effect.runPromiseExit(
    makeLongTask().pipe(Effect.timeout("1 second"))
  )
  console.log(exit)
  console.log("逾時結果 exit._tag =>", exit._tag) // "Failure"
}

runBasicTimeout().catch(console.error)

// 輸出:
// --- DEMO:基本 timeout 行為 ---
// [短任務] 開始
// [短任務] 結束
// 時限內完成 => OK
// [長任務] 開始
// 逾時結果 exit._tag => Failure

timeout 的基本行為與處理方式

  • 在時限內完成 ⇒ 回傳原本結果
  • 超過時限 ⇒ TimeoutException 失敗(可用 runPromiseExitexit 中的 Cause

補充:如何訂「明確上限」

數據驅動設定:

  • 有數據:觀察延遲分佈,取 p99 或 p99.9 數值,再加上 20% 緩衝。例如:p99 = 800ms,設定 timeout = 800ms × 1.2 = 960ms
  • 無數據:內部 RPC(微服務間調用)保守起手 1s,外部 API(第三方服務)2-5s,再依實測收斂

務必搭配備援:
這裡讀者可以自己想想如何仿照上一篇文章透過ScheduleEffect.retryOrElse 實現重試和降級策略。我就不贅述了。

timeoutOption:逾時就當作沒有值

什麼時候會希望 timeout 回傳空值?

  • 可選性操作:快取更新、非關鍵的資料同步、可選的增強功能
  • 優雅降級:外部 API 逾時時使用本地快取、資料庫查詢逾時時回傳預設值
  • 非阻塞式設計:避免單一操作失敗中斷整個流程,如批次處理中的個別項目

Option 代表「可選值」

timeout 逾時時會拋出 TimeoutException,需要錯誤處理。timeoutOption 逾時時會回傳 None。這樣我們就可以將其視為一個非錯誤的正常值對待。

❖ 補充:
我們先前的文章還沒有講過 Option 的相關概念,所以這裡先簡單介紹一下。詳細資料可以參考Option | Effect Docs

  • Option<A> 不是 Some<A>(有值),就是 None(沒有值)
  • 主要用途:初始值、可選欄位、可選參數、或回傳「不一定有」的結果

直接來看一下程式碼範例:

import { Option } from "effect";

// 建立有值的 Option
const some1 = Option.some(1)
console.log("建立有值的 Option =>", some1)
// { _id: 'Option', _tag: 'Some', value: 1 }

// 建立沒有值的 Option
const none = Option.none()
console.log("建立沒有值的 Option =>", none)
// { _id: 'Option', _tag: 'None' }

把概念放回 timeoutOption也是同樣意思:在期限內成功 ⇒ Some(value);逾時 ⇒ None

下面用「約 2 秒完成」的任務,同時套 3 秒與 1 秒兩種逾時來比較。

import { Effect, Option } from "effect";

// 約 2 秒完成的任務
const makeProcessingTask = () => {
  return Effect.gen(function*() {
    console.log("[任務] 開始處理...")
    yield* Effect.sleep("2 seconds")
    console.log("[任務] 處理完成。")
    return "Result"
  })
}

const runTimeoutOption = async () => {
  console.log("\n--- DEMO:timeoutOption(Some / None) ---")

  const task = makeProcessingTask()

  const results = await Effect.runPromise(
    Effect.all([
      task.pipe(Effect.timeoutOption("3 seconds")), // 有足夠時間 ⇒ Some("Result")
      task.pipe(Effect.timeoutOption("1 second")) // 時限太短 ⇒ None
    ])
  )

  console.log("results", results)
}

runTimeoutOption().catch(console.error)

// 輸出:
// --- DEMO:timeoutOption(Some / None) ---
// [任務] 開始處理...
// [任務] 處理完成。
// [任務] 開始處理...
// results [
//   { _id: 'Option', _tag: 'Some', value: 'Result' },
//   { _id: 'Option', _tag: 'None' }
// ]

可中斷(Interruptible) vs 不可中斷(Uninterruptible)

多數 Effect(例如 Effect.sleep、多數 I/O)預設是「可中斷」
(Interruptible)。逾時或手動中斷時,Runtime 會嘗試取消它,通常能很快停下。

相對地,Effect.uninterruptible 區塊內的程式碼在該區段「不接受中斷」。即使外層逾時,底層作業仍可能在背景繼續到自然結束。這也代表結果會在最終執行完函式後才拿到。

import { Effect } from "effect";

// 可中斷的任務
const interruptibleTask = () => {
  return Effect.gen(function*() {
    console.log("[可中斷] 開始")
    yield* Effect.sleep("2 seconds")
    console.log("[可中斷] 結束")
  })
}

// 不可中斷的任務(整段包在 uninterruptible)
const uninterruptibleTask = () => {
  const work = Effect.gen(function*() {
    console.log("[不可中斷] 進入")
    yield* Effect.sleep("2 seconds")
    console.log("[不可中斷] 離開")
  })
  return Effect.uninterruptible(work)
}

// 測試可中斷任務
const testInterruptibleTask = async () => {
  console.log("\n=== 測試 1:可中斷任務 + timeout ===")
  console.log("預期:任務會被中斷,不會看到「結束」訊息")

  const ex1 = await Effect.runPromiseExit(
    interruptibleTask().pipe(Effect.timeout("1 second"))
  )
  console.log("結果:", ex1._tag)
  if (ex1._tag === "Failure") {
    console.log("失敗原因:", ex1.cause._tag)
    if (ex1.cause._tag === "Fail") {
      console.log("TimeoutException:", ex1.cause.error)
    }
  }
}

// 測試不可中斷任務
const testUninterruptibleTask = async () => {
  console.log("\n=== 測試 2:不可中斷任務 + timeout ===")
  console.log("預期:任務不會被中斷,會看到「離開」訊息")

  const ex2 = await Effect.runPromiseExit(
    uninterruptibleTask().pipe(Effect.timeout("1 second"))
  )
  console.log("結果:", ex2._tag)
  if (ex2._tag === "Failure") {
    console.log("失敗原因:", ex2.cause._tag)
    if (ex2.cause._tag === "Fail") {
      console.log("TimeoutException:", ex2.cause.error)
    }
  }
}

// 執行測試
testInterruptibleTask().catch(console.error)

setTimeout(() => {
  testUninterruptibleTask().catch(console.error)
}, 3000)

測試結果觀察

測試 1:可中斷任務 + timeout

  • ✅ 預期正確:任務被中斷,沒有看到「結束」訊息
  • ✅ 行為符合預期:只看到「[可中斷] 開始」,沒有「[可中斷] 結束」
  • ❌ 結果:Failure 因為 TimeoutException

測試 2:不可中斷任務 + timeout

  • ✅ 預期正確:任務不會被中斷,看到「離開」訊息
  • ✅ 行為符合預期:看到「[不可中斷] 進入」和「[不可中斷] 離開」
  • ❌ 結果:Failure 因為 TimeoutException

實際應用場景

  • 可中斷任務:適合需要快速響應的場景(如 API 請求、用戶操作)
  • 不可中斷任務:適合關鍵操作(如資料庫事務、檔案寫入)需要確保完整性

下一篇我們會講解如何使用Effect.disconnect。它供了一種更靈活地處理不可中斷任務逾時的方法。它允許不可中斷任務在背景完成,而主控制流程則繼續執行,就像發生逾時一樣。

參考資料


上一篇
[學習 Effect Day16] Effect 進階錯誤管理 (二)
下一篇
[學習 Effect Day18] Effect 進階錯誤管理 (四)
系列文
用 Effect 實現產品級軟體22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言